Skip to content

fix(driver-memory,driver-sql): an explicit tenancy opt-out survives a partial syncSchema re-registration (#16729) - #17221

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-16729-memory-unique-sticky-opt-out
Sep 10, 2026
Merged

fix(driver-memory,driver-sql): an explicit tenancy opt-out survives a partial syncSchema re-registration (#16729)#17221
huangyiirene merged 3 commits into
mainfrom
claude/issue-16729-memory-unique-sticky-opt-out

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #16729

Clause-②: yes

An explicit tenancy.enabled: false opt-out is now sticky in driver-memory, the way it has been in driver-sql since #3249, so a partial syncSchema re-registration can no longer flip a platform-global object's UNIQUE partition from global to per-organization.

The reproduction, and the control that says the asymmetry is one-sided

Measured on origin/main d61139f1ba, before the fix. Same sequence on both drivers: register sys_license with tenancy.enabled: false, then re-register it the way a partial caller does — { name, fields } only — then write the same key under two different organizations.

second key: 'K', different organization
driver-memory, no re-registration REFUSEDUNIQUE_VIOLATION / 409
driver-memory, after the partial re-registration LANDED
driver-sql, no re-registration REFUSED
driver-sql, after the partial re-registration REFUSED — the record holds

At the export boundary, which is what the card measured:

tenantFieldOf(full)     = null                                    constraints(full)    = [{"field":"key","scopeField":null}]
tenantFieldOf(partial)  = "organization_id"                       constraints(partial) = [{"field":"key","scopeField":"organization_id"}]

SqlDriver.computeAndRecordTenantField('sys_license', ...) answered null for both schemas and tenantOptOutByTable.has('sys_license') stayed true. So the divergence was real, one-sided, and located exactly where the card said.

scopeField: null is one row per install — what the declaration asks for. scopeField: 'organization_id' is one row per organization. The flip is silent in both directions: nothing logs it, and the refusal message names the field, never the partition.

Rulings on the card that overrode this PR's original fence

Both are recorded on #16729 and both changed the plan. Naming them because the dispatch fence named only two files.

  1. Triage (comment 5578291123), acceptance item 3 assigns the driver-sql shard path to this card — "⛔ 不要另开卡,也 ⛔ 不要在修 driver-memory 时把它当作'以后再说'" — with its own required assertion: the same opted-out object must give the same scopeField through the shard path and the main-table path. That grants a wider surface than the fence (which named memory-unique-constraint.ts and lifecycle-service.ts), so packages/drivers/driver-sql/src/sql-driver.ts is in.
  2. PM seat (comment 5581730836) refuses the card's own suggested route: the fix must not reference memory-tenant-scope.ts / recordTenantField, because that code lives only on a stopped PR. Re-measured on today's origin/main d61139f1ba: memory-tenant-scope.ts absent, recordTenantField 0 hits — positive control, the same git ls-tree listed 58 entries including memory-unique-constraint.ts and memory-tenancy-guard.ts. The refusal still holds, so this PR carries its own record.

That seat also required a scan of #6915 and its siblings before building. Done: the #6915 maintainer ruling (comment 5261729371, 2026-08-12) is «裁定:处置 B —— 检测到多租户姿态时启动硬失败», and «⛔ 不做处置 A(实现行级租户隔离)». 处置 A is row-level tenant isolation — a read path that filters rows by organization. This PR adds none: it decides which partition a UNIQUE key occupies, and for the opted-out object it makes the key less scoped, not more. memory-tenancy-guard.ts is untouched and this driver still refuses a multi-tenant posture outright. So uniqueness-partition stickiness is not part of the refused disposition.

Landing site

Wider than the fence, per ruling 1, and one file wider than that:

file why
driver-memory/src/memory-unique-constraint.ts in the fence — the sticky resolver and the record type
driver-memory/src/memory-driver.ts beyond the fence. The record must be per driver instance; a module-level one would let one store's declaration decide another store's partition. syncSchema is where it is maintained, and that is where the card locates the defect
driver-memory/src/index.ts beyond the fence. Publishes the resolver beside tenantFieldOf
driver-sql/src/sql-driver.ts beyond the fence, granted by ruling 1
objectql/src/lifecycle/lifecycle-service.ts in the fence

No open PR holds any of these. Measured against all 20 open PRs' file lists; positive control fired (#17206 returned memory-analytics.ts / filter-refusal.ts, #17195 returned engine.ts), so the zero hits are a reading and not an empty query.

packages/spec/** is untouched — no spec change is implied. docs/adr/** is untouched, and check-adr-0087-registration.mjs --base origin/main exits 0, so no ADR row is owed.

The live caller: refuted, and the PR says so

The card's item 1 is to establish or refute a live caller handing syncSchema a schema with no tenancy block. Refuted in this tree. Whole-tree scan; control fired at 315 files mentioning syncSchema:

  • lifecycle-service.ts:1262 cold.syncSchema(object, obj) — the card's own candidate — passes the registry object, which carries the declaration.
  • engine.ts:14484 / :14534 and plugin.ts:1796 / :1807 pass the registry object too.
  • metadata/src/loaders/database-loader.ts:556 / :634 spread the full definition; SysMetadataObject declares no tenancy at all, so it has no opt-out to lose.
  • verify/src/date-bucket-parity.ts:185 and verify/src/read-coercion.ts:73 are the only literal { name, fields } calls. They are conformance harnesses over objects they create themselves, so no prior opted-out registration of the same name exists to be overwritten.

There is no live path today. This is a defensive closure, not a repair of a production failure — the wording acceptance item 1 asks for. See Acceptance notes for how this reads against the card's escalation trigger.

What changed

@objectstack/driver-memory gains computeAndRecordTenantField and the TenantOptOutRecord type. InMemoryDriver holds one record per instance and resolves through it in syncSchema, handing both declaration surfaces — field-level unique and declared indexes[] — the same resolved column, so one object's two keys cannot disagree about its partition. uniqueConstraintsFromFields and uniqueConstraintsFromDeclaredIndexes take that column as an optional second argument; called with one argument they answer exactly as before.

tenantFieldOf is unchanged. Triage item 2 is explicit that the existing pin at memory-unique-constraint.test.ts:346 is still correct for a pure function and must not be touched; it is not. The stickiness never lived in that function on the SQL side either — it lives in the wrapper around it, which is precisely the half that was not mirrored. The resolver is therefore published beside tenantFieldOf rather than kept private: publishing only the inner half is what let this package diverge, and the next driver reproducing the pair now sees both halves.

@objectstack/driver-sql: the shard leaf resolved with the bare computeTenantField, so a rotateShards sweep carrying no tenancy block gave a shard an organization key part the base table's index does not have — one object, two partitions, decided by which physical table a row landed in. It now resolves through the record, keyed by the base table (a new optional baseTable parameter defaulting to the shard name, so any external override keeps today's behaviour).

@objectstack/objectql: LifecycleObjectLike now declares tenancy. The Archiver hands that object straight to cold.syncSchema, and the published type refused the key while the driver below read it — so an author writing a fresh literal was pushed into producing exactly the partial shape above. Same correction #16711 made where the shard leaf narrowed indexes and tenancy off the object it was handed.

Both directions, proved

A fix that preserves the opt-out must not preserve it when a schema does carry an authoritative declaration that clears it.

  • Preserve: schema with no tenancy block, opt-out recorded ⇒ null. Pinned at the resolver and at the driver door, on both declaration surfaces.
  • Clear: a carried block is authoritative in both directions. tenancy: { enabled: true } and a block that declares no opt-out at all each delete the record, and the object is scoped again from then on. Pinned; the driver-door half uses the second spelling because the driver-memory 完全没有行级租户隔离(#3724 的未修姊妹面):多租户下静默不隔离 #6915 guard refuses an explicitly tenant-scoped object at that door outright.
  • Never record: an object that never declared the opt-out never enters the record, so a genuinely org-scoped object keeps organization_id across a partial re-registration — and its same-organization duplicate is still refused. This is triage item 4's negative control: an implementation answering null everywhere would make every positive assertion green while switching tenant isolation off.

Only the opt-out is sticky. A declared tenancy.tenantField is not recorded, so a partial re-registration of a custom-tenant-column object still falls back to organization_id — matching SqlDriver, whose behaviour this module's contract is to reproduce. That is the safer of the two readings where the evidence was ambiguous: recording more would be a second answer to a question driver-sql already answers.

The record is deliberately not cleared by dropTable, which does clear uniqueConstraints beside it. The asymmetry is principled and documented at the field: a constraint outliving its table would be enforced over a table nobody declared, whereas this record enforces nothing on its own — it only decides what the next declaration resolves to, and dropping a table is not a schema declaring itself tenant-scoped.

Assertions read uniqueConstraintsFromFields output and the driver's own refusal envelope (code and status, never merely "it threw"), not tenantFieldOf alone — triage item 5.

Verification

Gate exit codes captured by redirect-then-$?, never across a pipe.

  • Derived gate set: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack ⇒ 63 commands. All 63 exit 0. Reconciled: --ran reports 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. Includes check:tenant-chokepoint, check:platform-object-tenancy-census, check:driver-conformance, check:engine-double-contract, check:nul-bytes, check:changeset-no-major.
  • Two of those first returned exit 3 = PREREQUISITE NOT MET = NOT MEASURED, both purely "this worktree has no dist/": check:lean-entry-closure and check:dual-build-cjs-loads (with check:type-check-debt). The closure was built and all three re-run to exit 0 — reported as measured, not as a 3.
  • Tests: driver-memory 1190 passed / 49 files · driver-sql 2453 passed, 150 skipped / 175 files · objectql 4856 passed / 288 files. No failure to diagnose against fix(driver-memory): an analytics time dimension buckets by its declared granularity (#16178) #17206 or fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (#16344) #17195.
  • Typecheck: all three packages exit 0, including objectql's test-layer ledger — 44 files / 242 errors / 69 pinned signatures held, unchanged.
  • pnpm lint over the whole repo: exit 0. No narrowing claimed, so none owed.
  • Control characters: 0 hits across all nine changed files, with a control that fires (a probe carrying 0x01 and 0x7f matches; a tab-only probe correctly does not).

Ablation — each pin proved able to fail

Every leg mutated the committed tree, proved the mutation reached disk by hit-count and by git hash-object against the HEAD blob, ran, then restored under a trap ... EXIT INT TERM. Restoration proved by hash equality and an empty git diff HEAD, never by an exit code.

mutation result
driver-memory syncSchema stops consulting the record 2 of 10 red — the field-level and the declared-index door pins, the two that guard the wiring
driver-sql shard path back to the bare resolver 2 of 3 red; the org-scoped control stayed green, as it must
remove tenancy from LifecycleObjectLike diagnostics 4 to 5, the new one being exactly lifecycle-service.test.ts(950,7): error TS2353: Object literal may only specify known properties, and 'tenancy' does not exist in type 'LifecycleObjectLike'

⚠️ The type leg is reported through the test-layer program on purpose. Run against packages/objectql/tsconfig.json it exits 0 — that config carries exclude: ["**/*.test.ts"], so --listFiles counts zero test files and the reading is NOT MEASURED, not a pass. Re-run under tsconfig.test.json, whose --listFiles does contain the file, it is decisive. Recording the trap because the first reading looked like a clean green.

Semver

@objectstack/driver-memory minor · @objectstack/driver-sql patch · @objectstack/objectql patch.

  • driver-memory minor — two new public-entry exports (computeAndRecordTenantField, TenantOptOutRecord). New export from a published entry is minor on its own terms, independent of the behaviour repair.
  • The behaviour repairs are patch, not minor. The pre-fix answer was not one legal published answer being replaced by another: the object declared tenancy.enabled: false, isTenancyDisabled (ADR-0066) is the single source of truth for what that means, and the driver enforced the opposite partition. That is repairing an implementation that silently violated its own published declared contract.
  • objectql patch — a published type widening. A key the interface refused is now accepted; nothing that compiled before stops compiling. Not a narrowing, so no BREAKING marker and no adr-0087: line, and check-adr-0087-registration.mjs agrees at exit 0.
  • The optional second parameter on the two constraint builders is an accept-set widening: the one-argument call is byte-for-byte the previous behaviour.
  • ⛔ No major.

Acceptance notes

Out-of-scope observations, filed nowhere, recorded here.

  • The card's escalation trigger deserves a seat's read, not mine. It promises priority:p1 the moment someone names "一个具体的、把不含 tenancy 的 schema 传给 syncSchema 的调用方(文件 + 行 + 该处传入的对象形状)". Literally, packages/verify/src/date-bucket-parity.ts:185 and packages/verify/src/read-coercion.ts:73 are exactly that — driver.syncSchema(object, { name: object, fields: FIELDS }), no tenancy block. But neither can produce the harm: each creates its own conformance object, so there is no earlier opted-out registration of that name to overwrite. The trigger's letter is met and its purpose is not. Flagging rather than acting: I do not set labels.
  • LifecycleObjectLike narrows indexes the same way it narrowed tenancy. A fresh literal spelling indexes is refused by the type while cold.syncSchema reads the key and both drivers build uniqueness constraints from it — the identical [finding] SqlDriver reads keys off caller objects through (obj as any) at 7 sites while 3 parameter types declare none of them — a class, not a third coincidence (after #4311 tenancy, #16570 indexes) #16711 shape, on the identical interface, one key over. Left out deliberately: indexes is a different declaration key with its own ADR-0120 line and real DDL consequences on a SQL cold store, and it deserves its own measurement rather than a rider on a tenancy PR. Noted, not filed. Carrier: whoever next touches this interface, or a follow-up to this card.
  • resolveTenantField(shardName) at sql-driver.ts:9609 reads the shard's own bookkeeping entry for keyedColumns, a few lines before the code this PR corrected notes that shard bookkeeping is aliased only after the method runs. Not touched, not measured, and not obviously wrong — recording it because it sits inside the region this PR read closely. Noted, not filed.

Generated by Claude Code

…ky across a partial re-registration

`InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever
schema that call carried, so a second registration without a `tenancy` block
fell through to the implicit `organization_id` heuristic and moved a `unique`
field from one row per install to one row per organization. A duplicate the
declaration refuses then landed, silently.

`SqlDriver` has kept a sticky `tenantOptOutByTable` since #3249; this package
had mirrored the inner `computeTenantField` and not the wrapper that consults
the record.

- driver-memory: publish `computeAndRecordTenantField` + `TenantOptOutRecord`;
  `InMemoryDriver` holds one record per instance and hands both declaration
  surfaces the same resolved column. `tenantFieldOf` is unchanged.
- driver-sql: the shard leaf resolves through the record, keyed by the base
  table, instead of the bare `computeTenantField`.
- objectql: `LifecycleObjectLike` declares `tenancy`, so the published type no
  longer refuses a key the Archiver's `cold.syncSchema` reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…oor too

The field-level and declared-index constraints are wired through one resolved
tenant column; the door-level pin covered only the field surface, so a fix that
routed half of it would have stayed green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
The Archiver hands the cold driver the object it was given, and a driver
resolves a uniqueness partition from `tenancy`. Pins the key on the published
`LifecycleObjectLike` and on the `syncSchema` call in one assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/objectql, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via InMemoryDriver (symbol, a top-level class), SqlDriver (symbol, a top-level class), syncSchema (symbol, a method of class InMemoryDriver))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via InMemoryDriver (symbol, a top-level class), SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class), syncSchema (symbol, a method of class InMemoryDriver))
  • content/docs/protocol/objectql/query-syntax.mdx (via InMemoryDriver (symbol, a top-level class), SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class), syncSchema (symbol, a method of class InMemoryDriver))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via InMemoryDriver (symbol, a top-level class), syncSchema (symbol, a method of class InMemoryDriver))
  • content/docs/releases/v16.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via InMemoryDriver (symbol, a top-level class), SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-4.mdx (via SqlDriver (symbol, a top-level class), ensureRotation (symbol, a method of class SqlDriver), ensureShardTable (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3c5f3c5991fbf26693a0e99b70e76adaac11b4edpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e2ca364fa729e52137c640519d2e3704d9a328ca — the merge of head 0740437f5505c0f06b10f69cc4b8f1db8616faa3 into base 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e2ca364fa729e52137c640519d2e3704d9a328ca && git checkout e2ca364fa729e52137c640519d2e3704d9a328ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed 0740437f5505c0f06b10f69cc4b8f1db8616faa3 && git checkout -B drift-repro 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed && git merge --no-ff 0740437f5505c0f06b10f69cc4b8f1db8616faa3

node scripts/docs-audit/affected-docs.mjs --json 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS (audit reading; director seat, summon #18 segment 6, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00xxZ)

PR #17221 · head 0740437f5505c0f06b10f69cc4b8f1db8616faa3 (re-read at posting 00:04:15Z; unchanged since 18:29Z) · card #16729.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (140 harness model stamps, all claude-fable-5-1, zero residue; positive control 104 assistant / 37 user role tokens), adopted verbatim below.
  • Implemented-by: mode:subagent dev on branch claude/issue-16729-memory-unique-sticky-opt-out under PM seat session_01XTBcV7zZHmokdyQgXjbyEU (os-sam; newest Claim: 5605686033). Distinct sessions ⇒ not a self-review.
  • Reading for the seat: PASS, land as-is; one follow-up card owed by the seat at ACCEPT (F3, turso). No rework. ⛔ This seat cleared no carrier and touched no PR state at posting.

Verdict: PASS (contract-review tier) — land as-is; one follow-up card owed by the seat (F3), no rework.

Head reviewed: 0740437f5505c0f06b10f69cc4b8f1db8616faa3 (matches the 0740437f55 prefix; head did not move). Draft, base main, mergeable_state: clean. 3 commits over merge-base d61139f1ba; 9 files, +682/−10.

Clause-② reading: yes (mechanical floor — two new exports from the published driver-memory entry, one new key on the published LifecycleObjectLike interface, plus observable driver-behaviour change). PR body line 3 Clause-②: yes; card Claim comment 5605686033 Clause-②: yes; needs:contract-review on both carriers. Claim matches. node scripts/pm/check-clause2-carriers.mjs --pair 17221exit 0 ("both carriers agree").

Governed surface / protocol label: none of docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md touched. packages/spec/src/** untouched ⇒ no protocol:* rule (.github/labeler.yml:13-34) applies; none present. content/docs/releases/ untouched. check-adr-0087-registration not owed (widening, not narrowing).

CI on head: 43 check-runs pinned to 0740437f55: 35 success, 8 skipped, 0 red. Lint & Repo Gates = success, TypeScript Type Check = success (+ all four Type Check · jobs, Test Core 6/6, Temporal Conformance (live PG + MySQL)). No log to pull.

Findings

F1 — Published-face delta (all correct, matches the semver claim).

  • packages/drivers/driver-memory/src/index.ts:43-47,60 — new value export computeAndRecordTenantField, new type export TenantOptOutRecordminor. Correct.
  • memory-unique-constraint.ts:396-398, 442-444uniqueConstraintsFromFields / uniqueConstraintsFromDeclaredIndexes gain an optional trailing tenantField = tenantFieldOf(schema); one-arg call is byte-identical. Accept-set widening.
  • memory-unique-constraint.ts:361-377computeAndRecordTenantField(record, key, schema) mirrors SqlDriver.computeAndRecordTenantField (sql-driver.ts:9746-9756) arm for arm, including the != null "presence is authoritative" test. tenantFieldOf (:292-300) unchanged; triage-protected pin memory-unique-constraint.test.ts:346 untouched (file not in diff).
  • sql-driver.ts:9598-9602protected ensureShardTable(shardName, obj, baseTable = shardName); :9664-9669 shard leaf now computeAndRecordTenantField(baseTable, obj) instead of bare computeTenantField(obj); :9476 passes tableName. Verified aliasShardBookkeeping (:9696) copies the opt-out base→shard, and initObjects/registerSchema (:9823, :9896) key by the same base name, so the keying claim holds.
  • objectql/src/lifecycle/lifecycle-service.ts:111-129LifecycleObjectLike.tenancy?: {…} | null — new optional key on a published interface. Widening.
  • Shared driver contract untouched: packages/spec/src/contracts/data-driver.ts:334-338 (syncSchema — "Idempotent: creates tables if missing, adds columns, updates indexes", schema: unknown) says nothing about partial re-registration or partition stickiness. No error code added/changed; refusal reuses UNIQUE_VIOLATION_CODE/STATUS.

F2 — Correctness vs the card: reproduced, closed, both directions. Before: syncSchema('sys_license', {name, fields}) after a tenancy.enabled:false registration rebuilt uniqueConstraints with scopeField: 'organization_id' — cross-org duplicate landed. After (memory-driver.ts:2011-2014, the single call site): resolved through the per-instance tenantOptOutByObjectscopeField: null preserved, duplicate refused UNIQUE_VIOLATION/409; both declaration surfaces share one resolved column. dropTable (memory-driver.ts) leaves the record — verified SqlDriver.dropTable does not clear tenantOptOutByTable either, so the documented asymmetry is a faithful mirror. #6915 guard (memory-tenancy-guard.ts:207) refuses only explicit enabled === true, so the driver-door "clear" test's tenancy: {} spelling is justified. PM re-verified the "live caller refuted" evidence on the head: verify/src/date-bucket-parity.ts:185, verify/src/read-coercion.ts:73 are the only literal {name, fields} callers (self-created conformance objects, no prior opt-out); lifecycle-service.ts:1262 passes the registry object. "Defensive closure, not a production repair" wording is correct.

F3 — Other drivers: NOT all recorded; one bare-resolver site remains, unfiled. No conformance case-set covers unique scoping/tenancy (packages/spec/src/data/*-conformance.ts = aggregation, bulk-write-hook, filter-*, pagination, temporal, value-roundtrip; packages/verify/src/conformance.ts has none). The only released cross-driver claim is content/docs/releases/v16.mdx:355-360 ("drivers keep a sticky record of the opt-out across partial re-registrations"). Status per driver:

  • driver-sqlite-wasmextends SqlDriver (sqlite-wasm-driver.ts:67), inherits record + this PR's shard fix. Covered.
  • driver-mongodbmongodb-schema.ts:20-32: unique: true | 'global' both build a single-field index, no tenant partition; multi-tenant boot refused by its guard. Not affected.
  • driver-turso remote face — same defect shape, unrecorded. turso-driver.ts:703 wires remoteTransport.setTenantFieldResolver((schema) => this.computeTenantField(schema)) — the bare resolver — consumed at remote-transport.ts:2160 (buildUniqueIndexDDL), reached from remote-transport.ts:1817/1863 syncSchema. A partial remote re-registration of an opted-out object would rebuild the unique index with an org key part. The docblock (turso-driver.ts:694-702) rules out a by-table lookup (DDL runs before registerRemoteFieldMetadata), but computeAndRecordTenantField(object, schema) keyed by object name — exactly this PR's shard fix — would work. Beyond the fence and beyond triage's grant (item 3 named sql-driver.ts:9479 only), so not a rework; the seat should file a follow-up card citing this PR, the card's mongodb/sqlite-wasm readings above, and the absence of a shared case-set.

F4 — Scope vs rulings: compliant. Triage 5578291123 item 3 grants sql-driver.ts (done, with the required same-scopeField assertion). PM seat 5581730836 refuses any reference to memory-tenant-scope.ts/recordTenantField (PR carries its own record; 0 references, re-measured). #6915 disposition-B scan done and argued (uniqueness partition ≠ row-level isolation). Two beyond-fence files (memory-driver.ts, index.ts) are justified — per-instance record must live on the driver, and publishing only the inner half is what caused the divergence. Note for the seat: triage item 2 ("same record as the read path") is moot while #16733 is stopped; if #16733 ever lands, its read-side record and this tenantOptOutByObject must be unified — record that on #16589/#16733, not here.

F5 — Changeset: present and correctly graded. .changeset/memory-unique-sticky-tenancy-opt-out.md covers all three touched packages, all private: false (driver-memory 17.4.0 minor, driver-sql 17.4.0 patch, objectql 17.4.0 patch). No major; Check Changeset green. objectql patch for a new optional key on a published interface is the borderline call — accepted as an input-shape widening (nothing that compiled stops compiling; same class as the optional-parameter widening graded patch in the same PR).

F6 — Tests pin the contract, with controls. memory-unique-sticky-tenancy.test.ts (10 cases): preserve at resolver + both driver-door surfaces asserting code AND status; clear via enabled:true and tenancy:{}; org-scoped negative control that also still refuses the same-org duplicate (kills "always null"); per-instance isolation; tenantFieldOf purity. sql-driver-shard-tenancy-opt-out.test.ts (3 cases): white-box capture of the column handed to syncDeclaredIndexes on base vs shard, plus the org-scoped control — asserts the value that decides the partition, not an end-to-end collision (acceptable for a DDL path on :memory: sqlite). lifecycle-service.test.ts pins tenancy reaching cold.syncSchema; ablation reports TS2353 under tsconfig.test.json (correctly noted that tsconfig.json excludes tests and would read as a false green). No conformance-suite reuse — none exists for this surface (see F3). Minor: the per-instance test's refused leg is a catch-all, not an envelope match; the door tests beside it carry the envelope, so not a rework.

F7 — Docs: nothing owed. No hand-written page describes unique-partition stickiness (only v16.mdx:355-360, release-owned, already asserts the cross-driver claim this PR now makes true for memory). Docs-drift bot flagged 9 pages naming InMemoryDriver/syncSchema; none document partition semantics. content/docs/releases/ untouched.

F8 — PR form. Fixes #16729 correct (all triage acceptance items met or superseded by the PM-seat ruling); no closing keyword adjacent to any other open card number (#16589, #17206, #17195, #16711, #6915 appear bare). Gate evidence pinned to head d61139f1ba/0740437f5, exit codes via redirect-then-$?, VOID control discarded, --ran reconcile 63/63.

Acceptance notes


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Landing provenance — director seat, summon #18 segment 6 (session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00:13:20Z). Clearing needs:contract-review on both carriers (#17221 + card #16729) on the strength of the contract-review-tier PASS at #17221 (comment) (head 0740437f55, unchanged). Landing pre-checks (contract-review.md ①②③): ① tier verdict on the card (pointer posted); ② check-clause2-carriers.mjs --pair 17221 exit 0; ③ 33 check-runs on head, 0 red / 0 in progress; governed-surface test exit 0 (not governed); mergeable_state: clean. Next: ready → auto-merge (SQUASH) → merge-queue entry, per landing-operations.md. Executed under the maintainer's 2026-09-09 13:4xZ order 「把当前的契约复审全部处理完」 precedent; the dispatching seat keeps ACCEPT/landing-window duties (MERGED confirmation + card close-out) if it is back before the queue finishes — otherwise this seat closes out.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 10, 2026 00:14
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 0f38ab0 Sep 10, 2026
48 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-16729-memory-unique-sticky-opt-out branch September 10, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants